Содержание

  • 1  Знакомство с данными
    • 1.1  Визиты
    • 1.2  Покупки
    • 1.3  Реклама
  • 2  Предобработка
    • 2.1  Наименования столбцов
    • 2.2  Типы данных
    • 2.3  Пропуски
    • 2.4  Дубликаты
  • 3  Функции
    • 3.1  Функция для создания пользовательских профилей
    • 3.2  Функция для расчёта удержания
    • 3.3  Функция для расчёта конверсии
    • 3.4  Функция для расчёта LTV и ROI
    • 3.5  Функция для сглаживания данных
    • 3.6  Функция для визуализации удержания
    • 3.7  Функция для визуализации конверсии
    • 3.8  Функция для визуализации LTV и ROI
  • 4  Исследовательский анализ
    • 4.1  Даты
    • 4.2  Платящие пользователи
    • 4.3  Устройства
    • 4.4  Рекламные источники
  • 5  Маркетинг
    • 5.1  Расходы по каналам
    • 5.2  Динамика расходов на рекламу
    • 5.3  Стоимость привлечения одного пользователя
  • 6  Оценка окупаемости рекламы
    • 6.1  Общие графики
    • 6.2  Конверсия и удержание пользователей
      • 6.2.1  Конверсия
      • 6.2.2  Удержание
    • 6.3  Окупаемость рекламы по устройствам
    • 6.4  Окупаемость рекламы по странам
    • 6.5  Окупаемость рекламы по каналам привлечения
    • 6.6  Окупаемость рекламы в Европе и США
      • 6.6.1  Европа
      • 6.6.2  США
  • 7  Подведение итогов
    • 7.1  Вопросы
    • 7.2  Рекомендации
In [1]:
import pandas as pd
import numpy as np
import seaborn as sn
import matplotlib.pyplot as plt
from scipy import stats as st
from datetime import datetime, timedelta
import warnings
In [2]:
warnings.filterwarnings("ignore")
custom_params = {"axes.spines.right": False, "axes.spines.top": False}
sn.set(style="ticks", rc=custom_params)
pd.set_option('display.max_columns', None)
pd.set_option('display.max_colwidth', None)

Анализ резултатов рекламной кампании Procrastinate Pro+:

Наша компания занимается разработкой развлекательного приложения Procrastinate Pro+. За последние несколько месяцев было вложено большое кол-во средств на его продвижение в различных каналах. Результаты рекламной кампании неутешительные, фирма терпит убытки. В нашем распоряжении есть база данных о пользователях приложениях, их активности и покупках внутри него, а также рекламных затратах фирмы за период с мая по октябрь 2019 года.

Наша задача выяснить причины маркетинговых неудач и выяснить:

  • откуда приходят пользователи и какими устройствами они пользуются,
  • сколько стоит привлечение пользователей из различных рекламных каналов;
  • сколько денег приносит каждый клиент,
  • когда расходы на привлечение клиента окупаются,
  • какие факторы мешают привлечению клиентов.

Ответим на вопросы:

  • Окупается ли реклама, направленная на привлечение пользователей в целом?
  • Какие устройства, страны и рекламные каналы могут оказывать негативное влияние на окупаемость рекламы?
  • Чем могут быть вызваны проблемы окупаемости?

План работы:

1 этап. Общая информация о датасете.

  • Изучим базовую информацию о данных;
  • Получим данные о столбцах, кол-ве значений и типах данных;

2 этап. Предобработка.

  • Изучим датасеты, обозначим проблемы в данных;
  • Приведём в соответствие типы данных;
  • При необходимости устраним дублирующиеся значения, пропуски и прочие недостатки;

3 этап. Функции для анализа.

- Напишем функции для группировки данных и построения графиков;¶

4 этап. Исследовательский анализ.

  • Узнаем, откуда к нам приходят пользователи;
  • Изучим распределение устройств среди пользователей;
  • Посчитаем количество платящих пользователей;

5 этап. Маркетинговые показатели.

  • Посчитаем расходы на рекламу;
  • Узнаем стоимость привлечения одного пользователя;

6 этап. Оценка окупаемости рекламы.

  • Изучим графики удержания и конверсии;
  • Дадим оценку окупаемости рекламы в различных разрезах;
  • Найдём причины провала рекламной кампании;

7 этап. Итоги.

  • Подведём итоги и сформулируем рекомендации для отдела маркетинга;

Знакомство с данными¶

In [3]:
try:
    visits = pd.read_csv('H://Jupiter/visits_info_short.csv')
except:
    visits = pd.read_csv('https://code.s3.yandex.net/datasets/visits_info_short.csv')
In [4]:
try:
    purchases = pd.read_csv('H://Jupiter/orders_info_short.csv')
except:
    purchases = pd.read_csv('https://code.s3.yandex.net/datasets/orders_info_short.csv')
In [5]:
try:
    costs = pd.read_csv('H://Jupiter/costs_info_short.csv')
except:
    costs = pd.read_csv('https://code.s3.yandex.net/datasets/costs_info_short.csv')

Визиты¶

In [6]:
visits.head()
Out[6]:
User Id Region Device Channel Session Start Session End
0 981449118918 United States iPhone organic 2019-05-01 02:36:01 2019-05-01 02:45:01
1 278965908054 United States iPhone organic 2019-05-01 04:46:31 2019-05-01 04:47:35
2 590706206550 United States Mac organic 2019-05-01 14:09:25 2019-05-01 15:32:08
3 326433527971 United States Android TipTop 2019-05-01 00:29:59 2019-05-01 00:54:25
4 349773784594 United States Mac organic 2019-05-01 03:33:35 2019-05-01 03:57:40
In [7]:
visits.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 309901 entries, 0 to 309900
Data columns (total 6 columns):
 #   Column         Non-Null Count   Dtype 
---  ------         --------------   ----- 
 0   User Id        309901 non-null  int64 
 1   Region         309901 non-null  object
 2   Device         309901 non-null  object
 3   Channel        309901 non-null  object
 4   Session Start  309901 non-null  object
 5   Session End    309901 non-null  object
dtypes: int64(1), object(5)
memory usage: 14.2+ MB

Покупки¶

In [8]:
purchases.head()
Out[8]:
User Id Event Dt Revenue
0 188246423999 2019-05-01 23:09:52 4.99
1 174361394180 2019-05-01 12:24:04 4.99
2 529610067795 2019-05-01 11:34:04 4.99
3 319939546352 2019-05-01 15:34:40 4.99
4 366000285810 2019-05-01 13:59:51 4.99
In [9]:
purchases.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 40212 entries, 0 to 40211
Data columns (total 3 columns):
 #   Column    Non-Null Count  Dtype  
---  ------    --------------  -----  
 0   User Id   40212 non-null  int64  
 1   Event Dt  40212 non-null  object 
 2   Revenue   40212 non-null  float64
dtypes: float64(1), int64(1), object(1)
memory usage: 942.6+ KB

Реклама¶

In [10]:
costs.head()
Out[10]:
dt Channel costs
0 2019-05-01 FaceBoom 113.3
1 2019-05-02 FaceBoom 78.1
2 2019-05-03 FaceBoom 85.8
3 2019-05-04 FaceBoom 136.4
4 2019-05-05 FaceBoom 122.1
In [11]:
costs.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1800 entries, 0 to 1799
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype  
---  ------   --------------  -----  
 0   dt       1800 non-null   object 
 1   Channel  1800 non-null   object 
 2   costs    1800 non-null   float64
dtypes: float64(1), object(2)
memory usage: 42.3+ KB

Предобработка¶

Из базовой информации по датасетам видно, что пропусков в них нет. Проверять их на явные дубликаты смысла нет, т.к. по характеру данных в базе будет много повторяющихся значений и сумм значений. Итого на этапе предобработки нам нужно:

  • Привести наименования столбцов в надлежащий вид;
  • Изменить тип данных в столбцах с датами;
  • Проверить базы на наличие неявных дубликатов;

Наименования столбцов¶

Переименуем столбцы:

  • преведем названия к нижнему регистру;
  • заменим пробелы на подчеркивание;

Для оптимизации напишем функцию и применим ко всем трём датасетам:

In [12]:
def temp(data):
    data.columns = data.columns.str.lower()
    for x in data.columns:
        if ' ' in x:
            data = data.rename(columns = {x:x.replace(' ', '_')})
    return data
In [13]:
visits, purchases, costs = [temp(dataset) for dataset in [visits, purchases, costs]]
visits.sample()
Out[13]:
user_id region device channel session_start session_end
197584 483275927401 United States iPhone organic 2019-10-22 02:54:28 2019-10-22 02:56:28

Типы данных¶

Во всех датасетах приведём колонки с временем к соответствующему типу данных:

In [14]:
visits['session_start'] = pd.to_datetime(visits['session_start'])
visits['session_end'] = pd.to_datetime(visits['session_end'])

purchases['event_dt'] = pd.to_datetime(purchases['event_dt'])
costs['dt'] = pd.to_datetime(costs['dt']).dt.date
In [15]:
visits.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 309901 entries, 0 to 309900
Data columns (total 6 columns):
 #   Column         Non-Null Count   Dtype         
---  ------         --------------   -----         
 0   user_id        309901 non-null  int64         
 1   region         309901 non-null  object        
 2   device         309901 non-null  object        
 3   channel        309901 non-null  object        
 4   session_start  309901 non-null  datetime64[ns]
 5   session_end    309901 non-null  datetime64[ns]
dtypes: datetime64[ns](2), int64(1), object(3)
memory usage: 14.2+ MB
In [16]:
costs.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1800 entries, 0 to 1799
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype  
---  ------   --------------  -----  
 0   dt       1800 non-null   object 
 1   channel  1800 non-null   object 
 2   costs    1800 non-null   float64
dtypes: float64(1), object(2)
memory usage: 42.3+ KB
In [17]:
purchases.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 40212 entries, 0 to 40211
Data columns (total 3 columns):
 #   Column    Non-Null Count  Dtype         
---  ------    --------------  -----         
 0   user_id   40212 non-null  int64         
 1   event_dt  40212 non-null  datetime64[ns]
 2   revenue   40212 non-null  float64       
dtypes: datetime64[ns](1), float64(1), int64(1)
memory usage: 942.6 KB

Пропуски¶

Проверим датасеты на наличие пропусков:

In [18]:
for dataset in [visits, purchases, costs]:
    print(f'Кол-во пропусков: \n{dataset.isna().sum()}')
    print()
Кол-во пропусков: 
user_id          0
region           0
device           0
channel          0
session_start    0
session_end      0
dtype: int64

Кол-во пропусков: 
user_id     0
event_dt    0
revenue     0
dtype: int64

Кол-во пропусков: 
dt         0
channel    0
costs      0
dtype: int64

Пропуски в датасетах отсутствуют.

Дубликаты¶

Проверим датасеты на наличие дубликатов:

In [19]:
for dataset in [visits, purchases, costs]:
    print(f'Кол-во дубликатов в датасете: {dataset.duplicated().sum()}')
Кол-во дубликатов в датасете: 0
Кол-во дубликатов в датасете: 0
Кол-во дубликатов в датасете: 0

Явных дубликатов не обнаружено.


Проверим колонки со строковыми значениями на наличие неявных дубликатов:

In [20]:
display(visits['channel'].unique())
display(visits['device'].unique())
display(visits['region'].unique())

display(costs['channel'].unique()) 
array(['organic', 'TipTop', 'RocketSuperAds', 'YRabbit', 'FaceBoom',
       'MediaTornado', 'AdNonSense', 'LeapBob', 'WahooNetBanner',
       'OppleCreativeMedia', 'lambdaMediaAds'], dtype=object)
array(['iPhone', 'Mac', 'Android', 'PC'], dtype=object)
array(['United States', 'UK', 'France', 'Germany'], dtype=object)
array(['FaceBoom', 'MediaTornado', 'RocketSuperAds', 'TipTop', 'YRabbit',
       'AdNonSense', 'LeapBob', 'OppleCreativeMedia', 'WahooNetBanner',
       'lambdaMediaAds'], dtype=object)

Ошибочных / задвоенных строковых значений не обнаружено.


Вывод

Предобработка завершена:

  • Проверили датасеты на пропуски и дубликаты;
  • Скорректировали наименования столбцов;
  • Привели типы данных в соответствие;

На следующем этапе подготовим необходимые функции для анализа базы.

Функции¶

Функция для создания пользовательских профилей¶

In [21]:
def get_profiles(sessions, orders, ad_costs):

    # находим параметры первых посещений
    profiles = (
        sessions.sort_values(by=['user_id', 'session_start'])
        .groupby('user_id')
        .agg(
            {
                'session_start': 'first',
                'channel': 'first',
                'device': 'first',
                'region': 'first',
            }
        )
        .rename(columns={'session_start': 'first_ts'})
        .reset_index()
    )

    # для когортного анализа определяем дату первого посещения
    # и первый день месяца, в который это посещение произошло
    profiles['dt'] = profiles['first_ts'].dt.date
    profiles['month'] = profiles['first_ts'].astype('datetime64[M]')

    # добавляем признак платящих пользователей
    profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())

    # считаем количество уникальных пользователей
    # с одинаковыми источником и датой привлечения
    new_users = (
        profiles.groupby(['dt', 'channel'])
        .agg({'user_id': 'nunique'})
        .rename(columns={'user_id': 'unique_users'})
        .reset_index()
    )

    # объединяем траты на рекламу и число привлечённых пользователей
    ad_costs = ad_costs.merge(new_users, on=['dt', 'channel'], how='left')

    # делим рекламные расходы на число привлечённых пользователей
    ad_costs['acquisition_cost'] = ad_costs['costs'] / ad_costs['unique_users']

    # добавляем стоимость привлечения в профили
    profiles = profiles.merge(
        ad_costs[['dt', 'channel', 'acquisition_cost']],
        on=['dt', 'channel'],
        how='left',
    )

    # стоимость привлечения органических пользователей равна нулю
    profiles['acquisition_cost'] = profiles['acquisition_cost'].fillna(0)

    return profiles

Функция для расчёта удержания¶

In [22]:
def get_retention(
    profiles,
    sessions,
    observation_date,
    horizon_days,
    dimensions=[],
    ignore_horizon=False,
):

    # добавляем столбец payer в передаваемый dimensions список
    dimensions = ['payer'] + dimensions

    # исключаем пользователей, не «доживших» до горизонта анализа
    last_suitable_acquisition_date = observation_date
    if not ignore_horizon:
        last_suitable_acquisition_date = observation_date - timedelta(
            days=horizon_days - 1
        )
    result_raw = profiles.query('dt <= @last_suitable_acquisition_date')

    # собираем «сырые» данные для расчёта удержания
    result_raw = result_raw.merge(
        sessions[['user_id', 'session_start']], on='user_id', how='left'
    )
    result_raw['lifetime'] = (
        result_raw['session_start'] - result_raw['first_ts']
    ).dt.days

    # функция для группировки таблицы по желаемым признакам
    def group_by_dimensions(df, dims, horizon_days):
        result = df.pivot_table(
            index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
        )
        cohort_sizes = (
            df.groupby(dims)
            .agg({'user_id': 'nunique'})
            .rename(columns={'user_id': 'cohort_size'})
        )
        result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
        result = result.div(result['cohort_size'], axis=0)
        result = result[['cohort_size'] + list(range(horizon_days))]
        result['cohort_size'] = cohort_sizes
        return result

    # получаем таблицу удержания
    result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)

    # получаем таблицу динамики удержания
    result_in_time = group_by_dimensions(
        result_raw, dimensions + ['dt'], horizon_days
    )

    # возвращаем обе таблицы и сырые данные
    return result_raw, result_grouped, result_in_time 

Функция для расчёта конверсии¶

In [23]:
def get_conversion(
    profiles,
    purchases,
    observation_date,
    horizon_days,
    dimensions=[],
    ignore_horizon=False,
):

    # исключаем пользователей, не «доживших» до горизонта анализа
    last_suitable_acquisition_date = observation_date
    if not ignore_horizon:
        last_suitable_acquisition_date = observation_date - timedelta(
            days=horizon_days - 1
        )
    result_raw = profiles.query('dt <= @last_suitable_acquisition_date')

    # определяем дату и время первой покупки для каждого пользователя
    first_purchases = (
        purchases.sort_values(by=['user_id', 'event_dt'])
        .groupby('user_id')
        .agg({'event_dt': 'first'})
        .reset_index()
    )

    # добавляем данные о покупках в профили
    result_raw = result_raw.merge(
        first_purchases[['user_id', 'event_dt']], on='user_id', how='left'
    )

    # рассчитываем лайфтайм для каждой покупки
    result_raw['lifetime'] = (
        result_raw['event_dt'] - result_raw['first_ts']
    ).dt.days

    # группируем по cohort, если в dimensions ничего нет
    if len(dimensions) == 0:
        result_raw['cohort'] = 'All users' 
        dimensions = dimensions + ['cohort']

    # функция для группировки таблицы по желаемым признакам
    def group_by_dimensions(df, dims, horizon_days):
        result = df.pivot_table(
            index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
        )
        result = result.fillna(0).cumsum(axis = 1)
        cohort_sizes = (
            df.groupby(dims)
            .agg({'user_id': 'nunique'})
            .rename(columns={'user_id': 'cohort_size'})
        )
        result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
        # делим каждую «ячейку» в строке на размер когорты
        # и получаем conversion rate
        result = result.div(result['cohort_size'], axis=0)
        result = result[['cohort_size'] + list(range(horizon_days))]
        result['cohort_size'] = cohort_sizes
        return result

    # получаем таблицу конверсии
    result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)

    # для таблицы динамики конверсии убираем 'cohort' из dimensions
    if 'cohort' in dimensions: 
        dimensions = []

    # получаем таблицу динамики конверсии
    result_in_time = group_by_dimensions(
        result_raw, dimensions + ['dt'], horizon_days
    )

    # возвращаем обе таблицы и сырые данные
    return result_raw, result_grouped, result_in_time 

Функция для расчёта LTV и ROI¶

In [24]:
def get_ltv(
    profiles,
    purchases,
    observation_date,
    horizon_days,
    dimensions=[],
    ignore_horizon=False,
):

    # исключаем пользователей, не «доживших» до горизонта анализа
    last_suitable_acquisition_date = observation_date
    if not ignore_horizon:
        last_suitable_acquisition_date = observation_date - timedelta(
            days=horizon_days - 1
        )
    result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
    # добавляем данные о покупках в профили
    result_raw = result_raw.merge(
        purchases[['user_id', 'event_dt', 'revenue']], on='user_id', how='left'
    )
    # рассчитываем лайфтайм пользователя для каждой покупки
    result_raw['lifetime'] = (
        result_raw['event_dt'] - result_raw['first_ts']
    ).dt.days
    # группируем по cohort, если в dimensions ничего нет
    if len(dimensions) == 0:
        result_raw['cohort'] = 'All users'
        dimensions = dimensions + ['cohort']

    # функция группировки по желаемым признакам
    def group_by_dimensions(df, dims, horizon_days):
        # строим «треугольную» таблицу выручки
        result = df.pivot_table(
            index=dims, columns='lifetime', values='revenue', aggfunc='sum'
        )
        # находим сумму выручки с накоплением
        result = result.fillna(0).cumsum(axis=1)
        # вычисляем размеры когорт
        cohort_sizes = (
            df.groupby(dims)
            .agg({'user_id': 'nunique'})
            .rename(columns={'user_id': 'cohort_size'})
        )
        # объединяем размеры когорт и таблицу выручки
        result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
        # считаем LTV: делим каждую «ячейку» в строке на размер когорты
        result = result.div(result['cohort_size'], axis=0)
        # исключаем все лайфтаймы, превышающие горизонт анализа
        result = result[['cohort_size'] + list(range(horizon_days))]
        # восстанавливаем размеры когорт
        result['cohort_size'] = cohort_sizes

        # собираем датафрейм с данными пользователей и значениями CAC, 
        # добавляя параметры из dimensions
        cac = df[['user_id', 'acquisition_cost'] + dims].drop_duplicates()

        # считаем средний CAC по параметрам из dimensions
        cac = (
            cac.groupby(dims)
            .agg({'acquisition_cost': 'mean'})
            .rename(columns={'acquisition_cost': 'cac'})
        )

        # считаем ROI: делим LTV на CAC
        roi = result.div(cac['cac'], axis=0)

        # удаляем строки с бесконечным ROI
        roi = roi[~roi['cohort_size'].isin([np.inf])]

        # восстанавливаем размеры когорт в таблице ROI
        roi['cohort_size'] = cohort_sizes

        # добавляем CAC в таблицу ROI
        roi['cac'] = cac['cac']

        # в финальной таблице оставляем размеры когорт, CAC
        # и ROI в лайфтаймы, не превышающие горизонт анализа
        roi = roi[['cohort_size', 'cac'] + list(range(horizon_days))]

        # возвращаем таблицы LTV и ROI
        return result, roi

    # получаем таблицы LTV и ROI
    result_grouped, roi_grouped = group_by_dimensions(
        result_raw, dimensions, horizon_days
    )

    # для таблиц динамики убираем 'cohort' из dimensions
    if 'cohort' in dimensions:
        dimensions = []

    # получаем таблицы динамики LTV и ROI
    result_in_time, roi_in_time = group_by_dimensions(
        result_raw, dimensions + ['dt'], horizon_days
    )

    return (
        result_raw,  # сырые данные
        result_grouped,  # таблица LTV
        result_in_time,  # таблица динамики LTV
        roi_grouped,  # таблица ROI
        roi_in_time,  # таблица динамики ROI
    ) 

Функция для сглаживания данных¶

In [25]:
def filter_data(df, window):
    # для каждого столбца применяем скользящее среднее
    for column in df.columns.values:
        df[column] = df[column].rolling(window).mean() 
    return df 

Функция для визуализации удержания¶

In [26]:
def plot_retention(retention, retention_history, horizon, window=[]):

    # задаём размер сетки для графиков
    plt.figure(figsize=(15, 10))

    # исключаем размеры когорт и удержание первого дня
    retention = retention.drop(columns=['cohort_size', 0])
    # в таблице динамики оставляем только нужный лайфтайм
    retention_history = retention_history.drop(columns=['cohort_size'])[
        [horizon - 1]
    ]

    # если в индексах таблицы удержания только payer,
    # добавляем второй признак — cohort
    if retention.index.nlevels == 1:
        retention['cohort'] = 'All users'
        retention = retention.reset_index().set_index(['cohort', 'payer'])

    # в таблице графиков — два столбца и две строки, четыре ячейки
    # в первой строим кривые удержания платящих пользователей
    ax1 = plt.subplot(2, 2, 1)
    retention.query('payer == True').droplevel('payer').T.plot(
        grid=True, ax=ax1
    )
    plt.legend()
    plt.xlabel('Лайфтайм')
    plt.title('Удержание платящих пользователей')

    # во второй ячейке строим кривые удержания неплатящих
    # вертикальная ось — от графика из первой ячейки
    ax2 = plt.subplot(2, 2, 2, sharey=ax1)
    retention.query('payer == False').droplevel('payer').T.plot(
        grid=True, ax=ax2
    )
    plt.legend()
    plt.xlabel('Лайфтайм')
    plt.title('Удержание неплатящих пользователей')

    # в третьей ячейке — динамика удержания платящих
    ax3 = plt.subplot(2, 2, 3)
    # получаем названия столбцов для сводной таблицы
    columns = [
        name
        for name in retention_history.index.names
        if name not in ['dt', 'payer']
    ]
    # фильтруем данные и строим график
    filtered_data = retention_history.query('payer == True').pivot_table(
        index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
    )
    filter_data(filtered_data, window).plot(grid=True, ax=ax3)
    plt.xlabel('Дата привлечения')
    plt.title(
        'Динамика удержания платящих пользователей на {}-й день'.format(
            horizon
        )
    )

    # в чётвертой ячейке — динамика удержания неплатящих
    ax4 = plt.subplot(2, 2, 4, sharey=ax3)
    # фильтруем данные и строим график
    filtered_data = retention_history.query('payer == False').pivot_table(
        index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
    )
    filter_data(filtered_data, window).plot(grid=True, ax=ax4)
    plt.xlabel('Дата привлечения')
    plt.title(
        'Динамика удержания неплатящих пользователей на {}-й день'.format(
            horizon
        )
    )
    
    plt.tight_layout()
    plt.show() 

Функция для визуализации конверсии¶

In [27]:
def plot_conversion(conversion, conversion_history, horizon, window=[]):

    # задаём размер сетки для графиков
    plt.figure(figsize=(15, 5))

    # исключаем размеры когорт
    conversion = conversion.drop(columns=['cohort_size'])
    # в таблице динамики оставляем только нужный лайфтайм
    conversion_history = conversion_history.drop(columns=['cohort_size'])[
        [horizon - 1]
    ]

    # первый график — кривые конверсии
    ax1 = plt.subplot(1, 2, 1)
    conversion.T.plot(grid=True, ax=ax1)
    plt.legend()
    plt.xlabel('Лайфтайм')
    plt.title('Конверсия пользователей')

    # второй график — динамика конверсии
    ax2 = plt.subplot(1, 2, 2, sharey=ax1)
    columns = [
        # столбцами сводной таблицы станут все столбцы индекса, кроме даты
        name for name in conversion_history.index.names if name not in ['dt']
    ]
    filtered_data = conversion_history.pivot_table(
        index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
    )
    filter_data(filtered_data, window).plot(grid=True, ax=ax2)
    plt.xlabel('Дата привлечения')
    plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))

    plt.tight_layout()
    plt.show() 

Функция для визуализации LTV и ROI¶

In [28]:
def plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon, window=7):

    # задаём сетку отрисовки графиков
    plt.figure(figsize=(20, 10))

    # из таблицы ltv исключаем размеры когорт
    ltv = ltv.drop(columns=['cohort_size'])
    # в таблице динамики ltv оставляем только нужный лайфтайм
    ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]

    # стоимость привлечения запишем в отдельный фрейм
    cac_history = roi_history[['cac']]

    # из таблицы roi исключаем размеры когорт и cac
    roi = roi.drop(columns=['cohort_size', 'cac'])
    # в таблице динамики roi оставляем только нужный лайфтайм
    roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
        [horizon - 1]
    ]

    # первый график — кривые ltv
    ax1 = plt.subplot(2, 3, 1)
    ltv.T.plot(grid=True, ax=ax1)
    plt.legend()
    plt.xlabel('Лайфтайм')
    plt.title('LTV')

    # второй график — динамика ltv
    ax2 = plt.subplot(2, 3, 2, sharey=ax1)
    # столбцами сводной таблицы станут все столбцы индекса, кроме даты
    columns = [name for name in ltv_history.index.names if name not in ['dt']]
    filtered_data = ltv_history.pivot_table(
        index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
    )
    filter_data(filtered_data, window).plot(grid=True, ax=ax2)
    plt.xlabel('Дата привлечения')
    plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))

    # третий график — динамика cac
    ax3 = plt.subplot(2, 3, 3, sharey=ax1)
    # столбцами сводной таблицы станут все столбцы индекса, кроме даты
    columns = [name for name in cac_history.index.names if name not in ['dt']]
    filtered_data = cac_history.pivot_table(
        index='dt', columns=columns, values='cac', aggfunc='mean'
    )
    filter_data(filtered_data, window).plot(grid=True, ax=ax3)
    plt.xlabel('Дата привлечения')
    plt.title('Динамика стоимости привлечения пользователей')

    # четвёртый график — кривые roi
    ax4 = plt.subplot(2, 3, 4)
    roi.T.plot(grid=True, ax=ax4)
    plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
    plt.legend()
    plt.xlabel('Лайфтайм')
    plt.title('ROI')

    # пятый график — динамика roi
    ax5 = plt.subplot(2, 3, 5, sharey=ax4)
    # столбцами сводной таблицы станут все столбцы индекса, кроме даты
    columns = [name for name in roi_history.index.names if name not in ['dt']]
    filtered_data = roi_history.pivot_table(
        index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
    )
    filter_data(filtered_data, window).plot(grid=True, ax=ax5)
    plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
    plt.xlabel('Дата привлечения')
    plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))

    plt.tight_layout()
    plt.show()

Функции готовы к использованию. Можем переходить к анализу базы данных.

Исследовательский анализ¶

Сформируем профили пользователей с помощью заготовленной нами функции:

In [29]:
profiles = get_profiles(visits, purchases, costs)
print(profiles.head(5)) 
profiles.count()
    user_id            first_ts     channel  device         region  \
0    599326 2019-05-07 20:58:57    FaceBoom     Mac  United States   
1   4919697 2019-07-09 12:46:07    FaceBoom  iPhone  United States   
2   6085896 2019-10-01 09:58:33     organic  iPhone         France   
3  22593348 2019-08-22 21:35:48  AdNonSense      PC        Germany   
4  31989216 2019-10-02 00:07:44     YRabbit  iPhone  United States   

           dt      month  payer  acquisition_cost  
0  2019-05-07 2019-05-01   True          1.088172  
1  2019-07-09 2019-07-01  False          1.107237  
2  2019-10-01 2019-10-01  False          0.000000  
3  2019-08-22 2019-08-01  False          0.988235  
4  2019-10-02 2019-10-01  False          0.230769  
Out[29]:
user_id             150008
first_ts            150008
channel             150008
device              150008
region              150008
dt                  150008
month               150008
payer               150008
acquisition_cost    150008
dtype: int64

Даты¶

Узнаем самую раннюю и самую позднюю дату привлечения пользователя:

In [30]:
print('Самая ранняя дата:', profiles['first_ts'].min())
print('Самая поздняя дата:', profiles['first_ts'].max())
Самая ранняя дата: 2019-05-01 00:00:41
Самая поздняя дата: 2019-10-27 23:59:04

Таким образом, самый поздний новый пользователь появился в базе 27 октября 2019 года, самый ранний - 1 мая 2019 года.


Согласно тех. заданию нам требуется провести анализ на момент 01 ноября 2019 года с горизонтом анализа 14 дней. Т.е. самая поздняя дата, в которую должно попадать первое посещение пользователя - 17 ноября 2019 года.

Вывод: Имеющейся базы данных для выполнения пооставленной задачи достаточно.

Платящие пользователи¶

Выясним, из каких стран пользователи приходят в приложение и на какую страну приходится больше всего платящих пользователей. Построим таблицу, отражающую количество пользователей и долю платящих из каждой страны.

In [31]:
profiles.groupby('region').agg(
    {'user_id': 'count', 'payer': ['sum', 'mean']}
).sort_values(by=('payer', 'sum'), ascending=False).applymap(lambda x: '{:.2%}'.format(x) if isinstance(x, float) else x)
Out[31]:
user_id payer
count sum mean
region
United States 100002 6902 6.90%
UK 17575 700 3.98%
France 17450 663 3.80%
Germany 14981 616 4.11%

Вывод:

США находятся в лидерах как по общему кол-ву пользователей, так и по конверсии в покупку - 2/3 пользователей приложения находятся в этой стране, из них почти 7% совершают покупки.

Устройства¶

Узнаем, какими устройствами пользуются клиенты и какие устройства предпочитают платящие пользователи. Построим таблицу, отражающую количество пользователей и долю платящих для каждого устройства.

In [32]:
profiles.groupby('device').agg(
    {'user_id': 'count', 'payer': ['sum', 'mean']}
).sort_values(by=('payer', 'sum'), ascending=False).applymap(lambda x: '{:.2%}'.format(x) if isinstance(x, float) else x)
Out[32]:
user_id payer
count sum mean
device
iPhone 54479 3382 6.21%
Android 35032 2050 5.85%
Mac 30042 1912 6.36%
PC 30455 1537 5.05%

Вывод:

Распределение по устройствам входа достаточно равномерное, но лидерство с заметным отрывом у iPhone. Устройства от Apple составляют почти 60% от всей аудитории. Конверсия в покупку у них тоже чуть лучше - 6,2% и 6,3% на устройствах Apple против 5,8% и 5% на Android и PC.

Рекламные источники¶

Изучим рекламные источники привлечения и определим каналы, из которых пришло больше всего платящих пользователей. Построим таблицу, отражающую количество пользователей и долю платящих для каждого канала привлечения.

In [33]:
profiles.groupby('channel').agg(
    {'user_id': 'count', 'payer': ['sum', 'mean']}
).sort_values(by=('payer', 'sum'), ascending=False).applymap(lambda x: '{:.2%}'.format(x) if isinstance(x, float) else x)
Out[33]:
user_id payer
count sum mean
channel
FaceBoom 29144 3557 12.20%
TipTop 19561 1878 9.60%
organic 56439 1160 2.06%
WahooNetBanner 8553 453 5.30%
AdNonSense 3880 440 11.34%
RocketSuperAds 4448 352 7.91%
LeapBob 8553 262 3.06%
OppleCreativeMedia 8605 233 2.71%
lambdaMediaAds 2149 225 10.47%
YRabbit 4312 165 3.83%
MediaTornado 4364 156 3.57%

Вывод:

Лидерами по привлечению покупающих клиентов стали соцсети FaceBoom и TipTop - от них пришло 3557 и 1878 покупателей при конверсии 12% и 9,6%, соответственно. На третьем месте бесплатная для нас "органика".

Так же стоит отметить два источника с высокой конверсией, но с меньшим кол-вом пришедших пользователей (и покупателей): AdNonSense и lambdaMediaAds. У них 11% и 10% конверсия, но от них пришло в несколько раз меньше пользователей, чем от лидеров.


Вывод по разделу:

  • Сформировали профили пользователей;
  • Определели ключевые даты и удостоверились в достаточности данных в базе;
  • Ознакомились со сводными цифрами о рекламной кампании - конверсии по устройствам и рекламным источникам;

Ключевые тезисы:

  • Определили самый важный для нас рынок - США;
  • Через TipTop и FaceBoom к нам пришло больше всего пользователей и покупателей;

Маркетинг¶

Посчитаем общую сумму расходов на маркетинг.

In [34]:
print('Суммарные расходы на рекламу:', round(profiles['acquisition_cost'].sum(), 2))
Суммарные расходы на рекламу: 105497.3

Расходы по каналам¶

Выясним, сколько денег потратили на каждый источник:

In [35]:
costs_gr = profiles.groupby('channel')['acquisition_cost'].sum().sort_values(ascending=False).reset_index()
costs_gr['proportion'] = costs_gr['acquisition_cost'] / costs_gr['acquisition_cost'].sum()
costs_gr
Out[35]:
channel acquisition_cost proportion
0 TipTop 54751.30 0.518983
1 FaceBoom 32445.60 0.307549
2 WahooNetBanner 5151.00 0.048826
3 AdNonSense 3911.25 0.037074
4 OppleCreativeMedia 2151.25 0.020392
5 RocketSuperAds 1833.00 0.017375
6 LeapBob 1797.60 0.017039
7 lambdaMediaAds 1557.60 0.014764
8 MediaTornado 954.48 0.009047
9 YRabbit 944.22 0.008950
10 organic 0.00 0.000000

Безоговорочными лидерами по рекламному бюджету являются уже знакомые нам FaceBoom и TipTop. Вместе они съедают 82% бюджета.


В базе большое кол-во пользователей, пришедших через "органику". Учитывая цели исследования (причины провала рекламной компании), мы можем исключить "органику" из анализа, т.к. расходы на этот канал привлечения равны нулю. Дальнейшее расследование будем проводить только по коммерческим каналам.

In [36]:
profiles = profiles.query('channel != "organic"')

Динамика расходов на рекламу¶

Построим визуализацию динамики изменения расходов на рекламу во времени по каждому источнику. Сделаем это одновременно в двух разрезах: по неделям и месяцам.

In [37]:
# для этого в исходном датасете с расходами выделим недели и месяцы из даты
costs['week'] = pd.to_datetime(costs['dt']).dt.isocalendar().week
costs['month'] = pd.to_datetime(costs['dt']).dt.month
In [38]:
# График по неделям
ax1 = plt.subplot(2, 2, 1)
costs.pivot_table(
    index='week', columns='channel', values='costs', aggfunc='sum'
).plot(grid=True, figsize=(17, 7), ax=ax1)
plt.ylabel('CAC, $', fontsize=13)
plt.xlabel('№ недели', fontsize=13)
plt.title('Динамика САС по каналам привлечения (по неделям)' + '\n', fontsize=14)

# График по месяцам
ax2 = plt.subplot(2, 2, 2, sharey = ax1)
costs.pivot_table(
    index='month', columns='channel', values='costs', aggfunc='sum'
).plot(grid=True, figsize=(18, 15), ax=ax2)
plt.ylabel('CAC, $', fontsize=13)
plt.xlabel('№ месяца', fontsize=13)
plt.title('Динамика САС по каналам привлечения (по месяцам)' + '\n', fontsize=14)

plt.show()

Вывод:

С самого начала наблюдаемого периода можно заметить рост расходов по каналам TipTop и FaceBoom. Причем если по FaceBoom они после шестого месяца в некоторой степени стабилизируются, то рост расходов по TipTop продолжается и далее заметными темпами. Итого месяцчные затраты на TipTop в наблюдаемый период выросли более чем в 4 раза - с 3000 до 13000 долларов в месяц.


Скорее всего ощутимые убытки следует искать там, где расходы также велики. Однако сейчас рано делать поспешные выводы, нужно добыть ещё информации. Для этого копнём глубже.

Стоимость привлечения одного пользователя¶

Посчитаем, сколько в среднем стоило привлечение одного пользователя:

  1. Для всего проекта
  2. Для каждого источника рекламы
In [39]:
print('Стоимость привлечения пользователя для всей базы:', round(profiles['acquisition_cost'].mean(), 3), '$')
Стоимость привлечения пользователя для всей базы: 1.127 $

Посчитаем, сколько в среднем стоило привлечение одного пользователя из каждого источника:

In [40]:
profiles.groupby('channel')['acquisition_cost'].mean().sort_values(ascending=False)
Out[40]:
channel
TipTop                2.799003
FaceBoom              1.113286
AdNonSense            1.008054
lambdaMediaAds        0.724802
WahooNetBanner        0.602245
RocketSuperAds        0.412095
OppleCreativeMedia    0.250000
YRabbit               0.218975
MediaTornado          0.218717
LeapBob               0.210172
Name: acquisition_cost, dtype: float64

Эти же два рекламных канала являются лидерами по средней стоимости привлечения одного пользователя:

  • TipTop - 2.8 доллара на пользователя;
  • FaceBoom - 1.1 доллара на пользователя;
  • AdNonSense также как и первые два перевалил через границу 1 доллара на пользователя;

Остальные каналы расположились ниже на уровне среднего значения и ниже.


Чтобы понять много это или мало, нам нужно посчитать сколько покупатели приносят нам денег в обратную сторону.

Вывод по разделу:

  • Суммарно мы потратили 105497.3 $ за эту рекламную кампанию;
  • 82% бюджета съели FaceBoom и TipTop;
  • Мы узнали, что мае-июне в РК были внесены изменения и увеличен бюджет - как раз на FaceBoom и TipTop;
  • У этих же сетей самая высокая цена привлечения одного пользователя;

Дополнительно:

  • Из анализа исключены "органические" пользователи;

Оценка окупаемости рекламы¶

В этом разделе проанализируем окупаемость рекламы. Сначала в общем по базе, затем и в различных разрезах. За момент анализа возьмём 1 ноября 2019 года, горизонт - 14 дней.


В некоторых графиках для лучшей читаемости будем изменять окно сглаживания - от 7 до 14 дней.

Общие графики¶

Для начала построим графики LTV, ROI и CAC для всей базы:

In [41]:
observation_date = datetime(2019, 11, 1).date()  # момент анализа
horizon_days = 14 # горизонт анализа
In [42]:
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles, purchases, observation_date, horizon_days
)

plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days) 

Наблюдения:

  • Реклама не окупается. ROI к 14-му дню составляет лишь 80%;
  • CAC делает резкий скачок с мая-июня. После этого рост бюджета продолжается, но не столь стремительный;
  • LTV на протяжении исследуемого периода остаётся относительно стабильным. Значит, покупательское поведение пользователей в целом не изменилось после увеличения рекламных бюджетов;
  • Динамика ROI с мая-июня устремляется вниз.

Чтобы разобраться в причинах, пройдём по всем доступным характеристикам пользователей — стране, источнику и устройству первого посещения.

Конверсия и удержание пользователей¶

В этом разделе проверим конверсию и удержание пользователей, а также динамику их изменения.


Конверсия¶

Посмотрим на конверсию пользователей в нескольких разрезах:

  1. Общая конверсия
  2. Конверсия по устройствам
  3. Конверсия по каналам привлечения

1. Общая конверсия:

In [43]:
dimensions = []
conversion_raw, conversion_grouped, conversion_history = get_conversion(
    profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_conversion(conversion_grouped, conversion_history, horizon_days, 7) 

Можно сказать, что увеличением бюджета в мае-июне конверсия 14-го дня также выросла. Но этих графиков недостаточно, нужно углубляться в детали.

2. Конверсия по устройствам:

Построим графики конверсии пользователей в разрезе устройств.

Установим окно сглаживания в 14 дней

In [44]:
dimensions = ['device']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
    profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_conversion(conversion_grouped, conversion_history, horizon_days, 14) 

Наблюдения:

  • Глобально картина не поменялась: рост в мае-июне, затем стабилизация. Графики динамик конверсий для разных устройств имеют схожую форму и поведение;
  • ПК на обоих графиках заметно уступает по конверсии остальным платформам;

3. Конверсия по каналам привлечения:

Построим графики конверсии пользователей в разрезе каналов привлечения.

Установим окно сглаживания в 14 дней

In [45]:
dimensions = ['channel']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
    profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_conversion(conversion_grouped, conversion_history, horizon_days, 14) 

Наблюдения:

  • Лидер по конверсии - FaceBoom
  • Динамика конверсии у лидеров по рекламному бюджету TipTop и FaceBoom в мае-июне (когда случился резкий рост бюджета) остаётся стабильной.
  • В моментах в лидеры по конверсии выбивались LambdaMediaAds и AdNonSense. В остальном графики всех каналов можно назвать стабильными. У каждого свой рисунок, но из ряда вон выходящих явлений не замечено.

Удержание¶

По аналогии с конверсией изучим и удержание пользователей:

  1. Общее удержание
  2. Удержание по устройствам
  3. Удержание по каналам привлечения

3. Конверсия по каналам привлечения:

Установим окно сглаживания в 14 дней

In [46]:
dimensions = []
retention_raw, retention_grouped, retention_history = get_retention(
    profiles, visits, observation_date, horizon_days, dimensions=dimensions
)

plot_retention(retention_grouped, retention_history, horizon_days, 7) 

График динамики удержания достаточно стабилен, но есть слабый тренд на снижение.

Как и в случае с конверсией, общих данных недостаточно. Нужно смотреть в разрезах.


2. Удержание по устройствам:

Установим окно сглаживания в 14 дней

In [47]:
dimensions = ['device']
retention_raw, retention_grouped, retention_history = get_retention(
    profiles, visits, observation_date, horizon_days, dimensions=dimensions
)

plot_retention(retention_grouped, retention_history, horizon_days, 14) 

Наблюдения:

  • ПК с небольшим преимуществом лидирует по удержанию платящих пользователей;
  • Осенью динамика удержания на всех платформах более стабильна и предсказуема, чем летом. Возможно летом, в сезон отпусков и каникул у пользователей ниже потребность в прокрастинации :)

3. Конверсия по каналам привлечения:

Установим окно сглаживания в 7 дней

In [48]:
dimensions = ['channel']
retention_raw, retention_grouped, retention_history = get_retention(
    profiles, visits, observation_date, horizon_days, dimensions=dimensions
)

plot_retention(retention_grouped, retention_history, horizon_days, 7) 

Наблюдения:

  • Явные аутсайдеры по удержанию платящих пользователей - FaceBoom и AdNonSense: показатели удержания у них в 2-3 раза ниже остальных.
  • Другие каналы (в том числе TipTop) - держатся вместе примерно на одном уровне.

FaceBoom и TipTop уже ранее обратили на себя внимание резким ростом бюджетов. В антизаслуги FaceBoom теперь можно записать и крайне низкие показатели удержания покупателей. По этому же критерию в "подозрительные" можно отнести и AdNonSense.

Окупаемость рекламы по устройствам¶

Проанализируем окупаемость рекламы с разбивкой по устройствам. Построим графики LTV и ROI, а также графики динамики LTV, CAC и ROI.

In [49]:
dimensions = ['device']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Наблюдения:

  • Единственным устройством, на котором реклама окупилась - это ПК (но лишь на 11-й день);
  • Остальные платформы убыточны, в особенности Mac и iPhone с показателем ROI = 0.7 на 14-й день;
  • Тем не менее, динамика ROI неутешительна даже для ПК - осенью все платформы оказались ниже порога окупаемости.
  • С мая-июня динамика стоимости привлечения пользователей сильно выросла для Mac и iPhone. Андроид тоже, но в меньшей степени. ПК вырос незначительно.
  • Всё это имеет место при неизменной динамике LTV - пользователи не стали покупать больше и чаще, а стоимость их привлечения выросла.

Окупаемость рекламы по странам¶

Проанализируем окупаемость рекламы с разбивкой по странам. Построим графики LTV и ROI, а также графики динамики LTV, CAC и ROI.

In [50]:
dimensions = ['region']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Наблюдения:

Самое интересное оказалось в странах.

  • Покупатели из США приносят больше денег, чем покупатели из других регионов;
  • В мае-июне маркетинговый отдел существенно увеличил рекламный бюджет для США, а для остальных стран бюджет снизили;
  • Реклама в США не окупается - ROI на 14-й день не доходит до 70%. Реклама в остальных странах окупается на 5-6 день лайфтайма пользователя;
  • По графику динамики ROI видно, что до увеличения бюджета ROI был выше порога окупаемости. После мая-июня он опускается ниже этого порога и только снижается.

Окупаемость рекламы по каналам привлечения¶

Проанализируем окупаемость рекламы с разбивкой по рекламным каналам. Построим графики LTV и ROI, а также графики динамики LTV, CAC и ROI.

In [51]:
dimensions = ['channel']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles, purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Наблюдения:

  • Лидерами по LTV за лайфтайм являются TipTop и LambdaMediaAds;
  • У FaceBoom и AdNonSense со второго дня прирост LTV по сути прекращается. Что вполне логично, поскольку в графике удержания эти два источника показали наихудшие результаты.
  • LTV в динамике у большинства источников имеет схожую форму и поведение. Выделяется, пожалуй, только LambdaMediaAds нестабильностью и непредсказуемостью графика.
  • Реклама в TipTop, FaceBoom и AdNonSense не окупается. Худший результат у TipTop - чуть выше 50% на 14-й день;
  • За полгода ROI в динамике у FaceBoom ни разу не пересекал границу окупаемости. AdNonSense поднимался выше уровня окупаемости лишь 3 раза осенью, но стабильностью не отличался.
  • TipTop с ростом рекламного бюджета в мае-июне опустился ниже порога окупаемости, хотя изначально реклама там окупалась.
  • TipTop - единственный канал, по которому вырос CAC.
  • Маркетологам стоит обратить внимание на YRabbit - реклама там отлично отбивается.

Промежуточный вывод:

Нам известно, что:

  • Реклама в TipTop, FaceBoom и AdNonSense не окупается;
  • FaceBoom и AdNonSense - худшие по удержанию;
  • TipTop - единственный канал, по которому вырос CAC;
  • ROI по TipTop опустился ниже порога окупаемости после увеличения бюджета;
  • США - единственная страна, где вырос CAC. И здесь также ROI опустился ниже порога окупаемости после увеличения бюджета.

Есть предположение, что истории с TipTop и США связаны между собой. Проверим эту гипотезу.

Окупаемость рекламы в Европе и США¶

Проведём экспресс-анализ окупаемости рекламы в нескольких разрезах. Но в этот раз сделаем это отдельно для США и отдельно для остальных стран (Европы).

Европа¶

Рассмотрим окупаемость в двух разрезах:

  1. Устройства
  2. Каналы привлечения

Выводы сделаем после изучения обеих групп графиков.

In [52]:
dimensions = ['device']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles.query('region != "United States"'), purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 
In [53]:
dimensions = ['channel']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles.query('region != "United States"'), purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Наблюдения:

  • Реклама в Европе окупается на всех устройствах на 5-6 день. Напомним, что при изучении базы без разбивки по странам окупался лишь ПК в самом конце лайфтайма.
  • Если же смотреть на динамику ROI по устройствам, то видна закономерность: реклама на Mac и iPhone стала окупаться после снижения САС в мае-июне. Возможно, маркетологи снизили бюджет и изменили условия / настройки показов в Европе, чтобы освободить деньги для рынка США.
  • AdNonSense - вновь явный аутсайдер: дороже всех стоит привлечение, и при этом не окупается.
  • Лидерами по LTV и окупаемости в Европе является LambdaMediaAds;
  • TipTop и FaceBoom не используются для привлечения пользователей в Европе.

США¶

Рассмотрим окупаемость в двух разрезах:

  1. Устройства
  2. Каналы привлечения

Выводы также сделаем ниже, после изучения обеих групп графиков.

In [54]:
dimensions = ['device']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles.query('(region == "United States")'), purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 
In [55]:
dimensions = ['channel']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles.query('region == "United States"'), purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Наблюдения:

  • В США реклама не окупается ни на одном устройстве;
  • График динамики ROI по устройствам показывает, что реклама на мобильных (Android и iPhone) перестала окупаться после роста САС в мае-июне. До увеличения бюджетов линия ROI на мобильных была явно выше порога окупаемости. Мак и ПК же изначально чувствовали себя неважно.
  • LTV на всех устройствах выглядит вновь схоже и достаточно стабилен, разве что за некоторыми исключениями.

  • Реклама на TipTop и FaceBoom не окупается. Однако между ними есть важное для нас отличие: ROI у TipTop до мая-июня был в плюсе;

  • Остальные каналы привлечения в США стабильно окупаются;
  • Окупаемость YRabbit выглядит весьма многообещающе. Стоит обратить на это внимание маркетологов;
  • Как ранее мы и обнаружили, рост САС произошел только у TipTop.

Наиболее важным для нас нюансом является история с ROI у TipTop. Напрашивается график окупаемости по устройствам в США:

  1. Без TipTop
  2. Только TipTop

Взглянем на них:

Без TipTop:

In [56]:
dimensions = ['device']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles.query('(region == "United States") and (channel != "TipTop") '), purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Только TipTop:

In [57]:
dimensions = ['device']

ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
    profiles.query('(region == "United States") and (channel == "TipTop") '), purchases, observation_date, horizon_days, dimensions=dimensions
)

plot_ltv_roi(
    ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
) 

Наблюдения:

  • Реклама на TipTop приносит заметно больше денег, а график динамики LTV выглядит чуть более стабильным для всех устройств. К качеству трафика вопросов нет. Тем не менее, важно понимать, что увеличение бюджета никак не сказалось на LTV - ни в минус, ни в плюс.
  • До изменения бюджета график динамики ROI у TipTop был в ощутимом плюсе почти по всем устройствам, особенно на Android - реклама окупалась в 2.5 раза. Только ПК топтался около порога окупаемости.
  • Остальные рекламные каналы близки к окупаемости, но всё же ниже порога: от ~85% до ~97% в зависимости от устройства.
  • Изменение бюджета глобально не отразилось на график динамики ROI у остальных сетей - график нестабилен и чаще ниже порога окупаемости.

Подробный вывод опишем в заключительном разделе.

Подведение итогов¶

Вопросы¶

Ответим на поставленные в начале исследования вопросы:

Окупается ли реклама, направленная на привлечение пользователей в целом?

Если смотреть на общую картину, то рекламная стратегия компании убыточна. Однако ситуация оказывается гораздо глубже и сложнее при подробном изучении расходов и профилей пользователей в различных разрезах.

Какие устройства, страны и рекламные каналы могут оказывать негативное влияние на окупаемость рекламы?

Разные устройства и каналы привлечения могут вести себя по-разному в различных странах и сочетаниях между собой. Также стоит понимать, что в моменте неудачи по определенному рекламному каналу могут быть не связаны с ним самим непосредственно - они могут быть вызваны неудачными решениями в стратегии продвижения. Всё же можно выделить некоторые устойчивые тезисы:

  • FaceBoom в США и AdNonSense в Европе являются самыми дорогими по привлечению в своих регионах. Оба канала имеют проблемы с удержанием пользователей. Учитывая то, что наше приложение одинаковое для всех и у многих других сетей проблем с удержанием нет, можно предположить, что, используя эти каналы, мы просто не попадаем в нужную аудиторию. Как следствие - имеем проблемы и с окупаемостью.
  • TipTop по результатам полугодовой рекламной кампании можно отнести к рисковому каналу привлечения, но, вероятнее всего, многое зависит от методов закупа трафика (за методами следует и цена закупа) и тонких настроек продвижения.
  • В контексте устройств сложно выделить проблемные девайсы. Успех конкретного устройства может сильно зависеть от конкретных каналов привлечения и региона.

Чем могут быть вызваны проблемы окупаемости?

Основные убытки компания понесла из-за изменения маркетинговой стратегии на рынке США. Но нельзя сказать, что это произошло непосредственно из-за увеличения объёма закупа трафика, т.к. качество привлекаемого трафика не изменилось. Скорее всего, кроме увеличения бюджета произошли и другие изменения в стратегии продвижения:

  • Возможно, изменился метод оплаты рекламы. Компания продолжила привлекать тех же пользователей, но стала платить, например, не за клик по баннеру, а за регистрацию. Вкупе с увеличенным объёмом закупа расходы на рекламу очень быстро превысили выручку с этих пользователей.
  • Основная ставка была явно сделана на продвижение в TipTop. Изначально он показывал результаты лучше, чем у других сетей. По графику роста САС видно, что увеличение объёмов закупа происходило в несколько этапов. Ошибкой было не отслеживать промежуточные результаты рекламной кампании.
  • FaceBoom с долей 30% занимает второе место по объёму затрат на привлечение пользователей. Весь привлечённый трафик из этой соцсети не окупился. В среднем на каждом пользователе компания теряла до 20% суммы, затраченной на его привлечение.
  • Продвижение через AdNonSense в Европе также повлекло убытки, но доля расходов на этот канал составляет лишь 3.7% от общего бюджета.

Рекомендации¶

  1. Временно приостановить продвижение через FaceBoom и AdNonSense. Следует провести анализ аудитории каналов FaceBoom и AdNonSense на предмет соответствия портрету пользователя ProcrastinatePRO+. На основании результатов исследования принять решение о целесообразности дальнейшего продвижения через эти каналы.
  2. Детально изучить и, при необходимости, пересмотреть / откатить изменения в стратегии рекламной кампании в TipTop в мае 2019 года.
  3. Освободившийся рекламный бюджет поэтапно направить в Европу, без кардинальных изменений стратегии РК. Показатели окупаемости там надёжнее, чем в США.